'''
ocal.py -- a module for managing Oracle Calendar server command line
           utilities.

Author

    John Landahl <john@landahl.org>

External Dependencies

    Pexpect:  http://www.noah.org/wiki/Pexpect
    cfgparse: http://cfgparse.sourceforge.net/
    cmdln:    http://code.google.com/p/cmdln/

Background

    The web-based administration interface for Oracle Calendar (OCal) is quite
    limited, therefore much work must be done with command line utilities.
    Ordinarily this would make a sysadmin happy, but these utilities require
    one to enter a password with each invocation. In earlier versions of OCal
    this password could be included as a parameter, but this is a security
    risk and not recommended (due to this risk this option was removed in OCal
    10g).

    The result is that a sysadmin has to enter the OCal sysop password a
    number of times while performing day-to-day operations. At best this is a
    constant annoyance, but perhaps more importantly it acts as a barrier to
    writing scripts to simplify tasks, provide information or simplified
    access to calendar data, add local features on top of OCal, etc. There are
    C- and Java-based APIs for OCal, as well as a web services API, but they
    are limited to working on the data from a single account and cannot help
    with the administration of an entire OCal implementation.

Description

    The core feature of these utilities is the elimination of the need to
    enter a password with every invocation of an OCal command. This eliminates
    the barrier to scripting and general automation of OCal utilities.

    A default node number can be stored in a configuration file, and will be
    included with commands that do not explicitly specify a node number. This
    is very handy for OCal implementations of only one node. The usefulness of
    this can be seen even when running an arbitrary OCal command using the
    '-c' flag, where both the node number and the sysop password are provided:

        ocal.py -c "uniuser -y -del uid=xyz"

    A number of features are supplied for working with OCal accounts: a 'list'
    method provides features above the 'uniuser -ls' command, such as parsing
    each account into an Account object for easy attribute access. A simple
    caching mechanism can be used to store the list of Account objects between
    invocations. This is exceptionally useful when running multiple commands
    that query or otherwise work with the account list: it will be available
    almost immediately each time, rather than after ten or more seconds if
    it had to query OCal and parse the output each time.

    In the near future a full command line interface with subcommands and an
    interactive prompt will be provided for interacting with these utilities.

Approach

    The Pexpect library is used to interact with the OCal command line
    utilities. Specifically, it is used to enter the sysop password when
    prompted and then steps out of the way. All output from the command
    is then delivered in real-time, a very useful feature for commands
    that produce a large amount of output.

    The password can be kept in a .netrc file in the same way that Unix FTP
    clients manage passwords, or can be entered once at the beginning of a
    session and re-used for multiple invocations of OCal commands.
'''

import time, logging, re, pexpect, shelve

def read_password_from_netrc(machine, user):
    '''Given a machine name and user name, return the associated password
    in ~/.netrc. Returns None if not found or if no .netrc.'''
    import netrc
    password = None
    try:
        auth = netrc.netrc().authenticators(machine)
        if auth and auth[0] == user:
            password = auth[2]
    except IOError:
        pass
    return password

def execute_with_prompt(command, prompt, response, timeout=-1):
    '''Execute a system command which expects a single prompt (e.g. for a
    password) to be answered by the user before running.

    A timeout may be specified in seconds, where a value of -1 (a Pexpect
    convention) indicates that the default timeout (30 seconds) should be
    used.

    The result is a generator which produces each line of output as it is
    received from the command. This is especially advantageous for
    long-running commands or commands with a great deal of output, since the
    caller does not have to wait for the command to finish before receiving
    output, and the output will not take up memory as it would if collected
    into a list.

    Trailing CRLFs (included with each line coming from a pty) are stripped
    from each line.

    A simple example:

        for line in execute_with_prompt('sudo cat /etc/shadow', 'Password:', 'secret'):
            print line.split(':')[0:2]
    '''
    child = pexpect.spawn(command)
    child.expect(prompt, timeout=timeout)
    child.sendline(response)

    # compile a regular expression to find trailing CRLFs. note that we can't
    # simply use .rstrip() because it would strip -all- whitespace, which could
    # be inappropriate in some use cases.
    trailing_crlf = re.compile('\r\n$')

    return (trailing_crlf.sub('', line) for line in child)

def parse_uniuser_list(source):
    '''A generator which will parse an iterable containing lines of output 
    from a 'uniuser -ls' command and yield a new Account object for each entry.
    
    Given the following data sample:
    
    >>> data = """. S=Smith/G=John/OU1=org unit 1/OU2=org unit 2/OU3=org unit 3/
    ... + ID=1/EMAIL=abc@def.org/UID=abc/EMAIL-NOTIFDELIVERYRULE=PRIMARY/
    ... + EMAIL-REMINDERDELIVERYRULE=PRIMARY/ENABLE=TRUE/NODE-ID=999
    ... 
    ... . S=Jones/G=David/OU1=org\/unit\/1/OU2=org unit 2/OU3=org unit 3/ID=2/
    ... + EMAIL=xyz@def.org/UID=xyz/ENABLE=TRUE/NODE-ID=999""".splitlines()
    
    The following assertions will be true:
    
    >>> a1,a2 = tuple(parse_uniuser_list(data))
    >>> assert a1.first_name == 'John'
    >>> assert a1.last_name == 'Smith'
    >>> assert a1.email == 'abc@def.org'
    >>> assert a1.email_reminder_delivery_rule == 'PRIMARY'
    >>> assert a1.node_id == '999'
    >>> assert a2.first_name == 'David'
    >>> assert a2.last_name == 'Jones'
    >>> assert a2.email == 'xyz@def.org'
    >>> assert a2.ou1 == 'org/unit/1'
    '''

    accum = ''
    for line in source:
        if line.startswith('. '):
            if accum:
                # yield an Account object created from the string accumulator
                yield Account(accum)
            # reset the accumulator starting with data from the current line
            accum = line[2:]
        elif line.startswith('+ '):
            accum += line[2:]

    if accum:
        yield Account(accum)

unescaped_slashes = re.compile(r'(?<!\\)/')

class Account:
    attr_map = {
        'g': 'first_name',
        's': 'last_name',
        'r': 'resource',
        'email_notifdeliveryrule': 'email_notify_delivery_rule',
        'email_reminderdeliveryrule': 'email_reminder_delivery_rule'
    }

    def __init__(self, source=None):
        self.first_name = None
        self.last_name  = None
        self.resource   = None
        self.ou1        = None
        self.ou2        = None
        self.ou3        = None
        self.a          = None # TODO: find better name for this field
        self.id         = None
        self.email      = None
        self.uid        = None
        self.enabled    = None
        self.node_id    = None
        self.email_notify_delivery_rule = None
        self.email_reminder_delivery_rule = None

        if source:
            self.update_from_ocal_string(source)

    def update_from_ocal_string(self, source):
        '''Parse a string from the 'uniuser' command and store values into the
        appropriate attributes. The following is an example string:

          S=Smith/G=John/OU1=org unit 1/OU2=org unit 2/ID=1/EMAIL=abc@def.org/UID=abc
        '''

        # parse the source string into attr,val pairs first by splitting on
        # slashes (ignoring any escaped slashes, i.e. "\/"), then again on equals
        data = [f.split('=') for f in unescaped_slashes.split(source)]

        logging.debug('update_from_ocal_string: data=%s', data)
        for attr, val in data:
            # change dashes to underscores in the attr name and convert it 
            # to lower case
            attr = re.sub('-', '_', attr).lower()

            # unescape any escaped slashes
            if '\\/' in val:
                val = val.replace('\\/', '/')

            setattr(self, attr, val)

    def __getattr__(self, name):
        attr = self.attr_map.get(name, None)
        if attr:
            return self.__dict__[attr]
        else:
            raise AttributeError

    def __setattr__(self, name, value):
        attr = self.attr_map.get(name, name)
        self.__dict__[attr] = value

    def __str__(self):
        if self.first_name and self.last_name:
            s = '%s %s' % (self.first_name, self.last_name)
        elif self.last_name:
            s = self.last_name
        else:
            s = ''

        if self.resource:
            if s: s = '%s (%s)' % (self.resource, s)
            else: s = self.resource

        if self.email:
            if s: s += ' <%s>' % self.email
            else: s = self.email

        return s 
    
class SimpleAccountCache(object):
    def __init__(self, location, expire=86400):
        self.location = location
        self.expire = expire
        self.producer = lambda: []
        self._shelf = shelve.open(location)

    def expired(self):
        return (time.time() - self._shelf.get('last_update', 0) > self.expire)

    def update(self):
        shelf = self._shelf
        shelf['accounts'] = tuple(self.producer())
        shelf['last_update'] = time.time()
        shelf.sync()

    def get_accounts(self):
        if self.expired():
            self.update()
        return self._shelf['users']
    accounts = property(get_accounts)

class OcalUtil:
    def __init__(self, node, password=None, account_cache=None, trial_run=False):
        self.node = node
        self.password = password
        self.trial_run = trial_run

        self.account_cache = account_cache
        if self.account_cache:
            account_cache.producer = lambda: self.list(parse=True)

    def execute(self, command, password=None, timeout=-1, ignore_blanklines=False):
        password = password or self.password
        if not password:
            raise ValueError, 'password cannot be blank'

        node_string = '-n %d' % self.node
        if node_string not in command:
            # add the node string if not provided, but inserting it in
            # place of the first string.
            command = command.replace(' ', ' %s ' % node_string, 1)

        if self.trial_run:
            logging.info('Command (trial run): %s', command)
            raise StopIteration
        else:
            logging.debug('Running command: %s', command)
            for line in execute_with_prompt(command,
                                            prompt='password:', response=password,
                                            timeout=timeout):
                # remove any trailing whitespace
                line = line.rstrip()

                # skip this line if blank and ignore_blanklines is true
                if ignore_blanklines and not line: continue

                yield line

    def delete(self, account, **kwargs):
        '''Delete an account, where 'account' should be a valid Oracle Calendar 
        account specifier, e.g. "uid=foo" or "g=Bob/s=Smith". Resources should be
        specified as "r=resource".'''

        # if the account spec includes "r=", add a "-resource" flag to the
        # uniuser command.
        if re.match('[Rr]=', account):
            resource_flag = '-resource'
        else:
            resource_flag = ''

        # wrap the account spec with quotes if it contains spaces. this
        # happens often with resources.
        if re.match('/s+', account):
            account = '"%s"' % account

        # run "uniuser -del"; note that "-y" is crucial here to avoid an "are
        # you sure" prompt.
        command = 'uniuser -y %s -del %s' % (resource_flag, account)
        return self.execute(command, **kwargs)

    def mass_delete(self, func):
        pass

    def restore(self, path, uid, **kwargs):
        command = 'unirestore -futureReplies -noAddAttendee -u %s -path %s' % (uid, path)
        return self.execute(command, **kwargs)

    def export(self, account, resource=False, **kwargs):
        # TODO: add support for resources (-resource flag on uniical?)
        command = 'uniical -export -u %s' % account
        return self.execute(command, **kwargs)

    def list(self, account='', format='', resource=False, parse=False, **kwargs):
        # TODO: add support for resources
        if format:
            command = 'uniuser -ls -format %s %s' % (format, account)
        else:
            command = 'uniuser -ls %s' % account

        results = self.execute(command, **kwargs) 
        if parse:
            return parse_uniuser_list(results)
        else:
            return results

    def accounts(self):
        if self.account_cache:
            return self.account_cache.accounts
        else:
            return self.list(parse=True)

def configure():
    import sys, optparse, cfgparse, cmdln
    from os.path import splitext, exists

    class Commands(cmdln.Cmdln):
        @cmdln.alias('ls')
        def do_list(self, *args):
            print args
        def do_quit(self, *args):
            return True
        do_EOF = do_quit

#    opt = cmdln.CmdlnOptionParser()
    opt = optparse.OptionParser()
    opt.add_option('--command', '-c',  dest='command')
    opt.add_option('--node', '-n',     dest='node', type='int')
    opt.add_option('--password',       dest='password')
    opt.add_option('--netrc-machine',  dest='netrc_machine')
    opt.add_option('--netrc-user',     dest='netrc_user')
    opt.add_option('--prompt', '-P',   dest='prompt', action='store_true')
    opt.add_option('--test',           dest='test', action='store_true')
    opt.add_option('--interact', '-i', dest='interact', action='store_true', default=False)
    opt.add_option('--trial-run', '--no-run', dest='trial_run', action='store_true')

    cfg = cfgparse.ConfigParser()
    cfg.add_option('node',          default=0)
    cfg.add_option('netrc_machine', default='ocal')
    cfg.add_option('netrc_user',    default='SYSOP')
    cfg.add_option('trial_run',     default=False)
    cfg.add_optparse_help_option(opt)
    cfg.add_optparse_files_option(opt, switches=('--conf', '-f'), dest='conf_files')

    # default config file: path of the script (minus any extension) plus '.ini'
    command_base = splitext(sys.argv[0])[0]
    default_config = command_base + '.ini'
    if exists(default_config):
        cfg.add_file(default_config)

    return cfg.parse(opt)

def main(opts, args):
    from getpass import getpass

    logging.basicConfig(level=logging.DEBUG,
                        format='%(asctime)s %(levelname)s %(message)s')

    password = opts.password or read_password_from_netrc(opts.netrc_machine, opts.netrc_user)
    if not password:
        if opts.prompt:
            password = getpass()
        else:
            print "Missing password, cannot continue"
            sys.exit(1)

    cache = SimpleAccountCache('/ctime/work/users.cache')
    util = OcalUtil(opts.node, password, account_cache=cache, trial_run=opts.trial_run)

#     cmd = Commands()
#     cmd.cmdqueue += args
#     if not opts.interact:
#         cmd.cmdqueue.append('quit')
#     cmd.cmdloop()

    if opts.command:
        for line in util.execute(opts.command):
            print line

    print sum(1 for u in util.list(format='%uid%', ignore_blanklines=True))
    print sum(1 for u in util.accounts())

if __name__ == '__main__':
    opts, args = configure()
    if opts.test:
        import doctest
        doctest.testmod()
    else:
        main(opts, args)
